/**
 * Copyright 2012 Alfredo "Rainbowbreeze" Morresi
 * 
 * This file is part of Eureka! project.
 * 
 * Eureka! is free software: you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later
 * version.
 * 
 * Eureka! is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
 * details.
 * 
 * You should have received a copy of the GNU General Public License along
 * with Eureka!. If not, see http://www.gnu.org/licenses/.
 */
package it.rainbowbreeze.eureka.logic;

import it.rainbowbreeze.eureka.common.LogFacility;
import it.rainbowbreeze.eureka.common.ResultOperation;
import it.rainbowbreeze.eureka.data.QualityDataPointDao;
import it.rainbowbreeze.eureka.domain.QualityDataPoint;
import it.rainbowbreeze.libs.jobexecutor.RainbowJobResult;

import java.util.ArrayList;
import java.util.List;

import android.content.Context;
import android.graphics.Rect;

import com.google.android.maps.GeoPoint;

/**
 * 
 * @author Alfredo "Rainbowbreeze" Morresi
 *
 */
public class QualityDataPointManager {
    // ------------------------------------------ Private Fields
    private static final String LOG_HASH = QualityDataPointManager.class.getSimpleName();

    //as wikipedia told: http://en.wikipedia.org/wiki/Decimal_degrees
    private static final int MIN_MAP_LATITUDE = -90000000; //horizontally
    private static final int MIN_MAP_LONGITUDE = -180000000; //vertically
    private static final int MAX_MAP_LATITUDE = 90000000;
    private static final int MAX_MAP_LONGITUDE = 180000000;
    
    /** less that one hour old data are considered still good */
    private static final long RELEVATION_TIME_TOLERANCE = 3600 * 1000;
    private static final double RADIUS_TOLERANCE_PERCENTACE_MAX = 1.20; //20% of radius tolerance
    private static final double RADIUS_TOLERANCE_PERCENTACE_MIN = 0.80; //20% of radius tolerance

    private final LogFacility mLogFacility;
    private final QualityDataPointDao mQualityDataPointDao;
    private final JobExecutor mJobExecutor;
    private final JobFactory mJobFactory;


    private DataAvailableListener mListener;

    // -------------------------------------------- Constructors
    public QualityDataPointManager(
            LogFacility logFacility,
            QualityDataPointDao qualityDataPointDao,
            JobExecutor jobExecutor,
            JobFactory jobFactory) {
        mLogFacility = logFacility;
        mQualityDataPointDao = qualityDataPointDao;
        mJobExecutor = jobExecutor;
        mJobFactory = jobFactory;
        
        mJobExecutor.registerListener(LOG_HASH, mJobExecutorListener);
    }
    
    @Override
    public void finalize() throws Throwable {
        mJobExecutor.deregisterListener(LOG_HASH);
    }
    

    // --------------------------------------- Public Properties
    
    /**
     * Listener called when new data are available to display
     */
    public interface DataAvailableListener {
        /**
         * 
         * @param searchKey search key used to identify the search
         * @param resultData data obtained from the search
         */
        void dataIsAvailable(String searchKey, List<QualityDataPoint> resultData);
    }

    public void registerDataAvailableListener(DataAvailableListener newListener) {
        mListener = newListener;
    }
    
    public void unregisterDataAvailableListener() {
        mListener = null;
    }
    
    
    // ------------------------------------------ Public Methods
    
    /**
     * 
     * @param appContext
     * @param center
     * @param latitudeSpan
     * @param longitudeSpan
     * @param zoom
     * @param categotyId
     * @param measureId
     * 
     * @return 
     * 
     * @return request key to look for in the result pool
     */
    public String getDataPointsForGivenBoudaries(
            Context appContext,
            GeoPoint center,
            int latitudeSpan,
            int longitudeSpan,
            int zoom,
            int[] measureIds) {
        
        String jobSearchKey = createKeyForRequest(center, latitudeSpan, longitudeSpan, zoom);
        
        //finds what tiles are involved for given coordinates
        Rect tiles = findTiles(center, latitudeSpan, longitudeSpan, zoom);
        mLogFacility.v(LOG_HASH, "Finding points for tiles [" + tiles.top + "," + tiles.left + "][" + tiles.bottom + "," + tiles.right + "]");
        
        //transform tiles in coordinates
        GeoPoint topLeft = getTileCoordinate(tiles.top, tiles.left, zoom, true);
        GeoPoint bottomRight = getTileCoordinate(tiles.bottom, tiles.right, zoom, false);
        
        //calculate tolerance timestamp (now - 1 hour in the past)
        long minReadingTime = System.currentTimeMillis() - 3600000;
        
        //calculate radius
        float radius = distFrom(topLeft.getLatitudeE6(), topLeft.getLongitudeE6(), bottomRight.getLatitudeE6(), bottomRight.getLongitudeE6());
        //now, search for the points in the given coordinates
        int radiusMax = (int) (radius * RADIUS_TOLERANCE_PERCENTACE_MAX);
        int radiusMin = (int) (radius * RADIUS_TOLERANCE_PERCENTACE_MIN);
        
        //finds if we already have stored data for required parameter
        List<QualityDataPoint> dataPoints = mQualityDataPointDao.getPointsAround(
                appContext,
                topLeft.getLatitudeE6(),
                topLeft.getLongitudeE6(),
                bottomRight.getLatitudeE6(),
                bottomRight.getLongitudeE6(),
                radiusMin,
                radiusMax,
                minReadingTime);

        //data found, notify the listener
        if (null != dataPoints && dataPoints.size() > 0) {
            if (null != mListener) {
                mListener.dataIsAvailable(jobSearchKey, dataPoints);
            }
            
        //a recent, similar search returned an empty result
        } else if (searchWasPerformedInTheLastPeriod()) {

        //a similar search is already in the queue
        } else if (isSimilarSearchAlreadyInQueue(jobSearchKey)) {
            //move the task to higher priority
        
        //time to starts a new search
        } else {
            
            DownloadQualityDataJob job = mJobFactory.createDownloadQualityDataJob(
                    jobSearchKey,
                    center.getLatitudeE6(),
                    center.getLongitudeE6(),
                    (int) radius,
                    measureIds);
            mJobExecutor.submitJob(job);
        }
        
        return jobSearchKey;
    }
    
    public Rect findTiles(GeoPoint center, int latitudeSpan, int longitudeSpan, int zoom) {
        //find boundaries
        int lat1 = (center.getLatitudeE6() - (latitudeSpan / 2));
        int lng1 = (center.getLongitudeE6() - (longitudeSpan / 2));
        int lat2 = (center.getLatitudeE6() + (latitudeSpan / 2));
        int lng2 = (center.getLongitudeE6() + (longitudeSpan / 2));
        
        //translation
        lat1 = lat1 - MIN_MAP_LATITUDE;
        lat2 = lat2 - MIN_MAP_LATITUDE;
        lng1 = lng1 - MIN_MAP_LONGITUDE;
        lng2 = lng2 - MIN_MAP_LONGITUDE;
        
        //finds latitude and longitude tile size
        int tileLat = (MAX_MAP_LATITUDE - MIN_MAP_LATITUDE) / zoom;
        int tileLng = (MAX_MAP_LONGITUDE - MIN_MAP_LONGITUDE) / zoom;
        
        //finds in which tiles current boundaries span
        int minTileLatIndex = lat1 / tileLat;
        int maxTileLatIndex = lat2 / tileLat;
        int minTileLngIndex = lng1 / tileLng;
        int maxTileLngIndex = lng2 / tileLng;
        
        Rect rect = new Rect();
        rect.left = minTileLngIndex;
        rect.top = minTileLatIndex;
        rect.bottom = maxTileLatIndex;
        rect.right = maxTileLngIndex;
        return rect;
    }
    

    public String createKeyForRequest(
            GeoPoint center,
            int latitudeSpan,
            int longitudeSpan,
            int zoom) {
        //transform tiles in coordinates
        Rect tiles = findTiles(center, latitudeSpan, longitudeSpan, zoom);
        GeoPoint topLeft = getTileCoordinate(tiles.top, tiles.left, zoom, true);
        GeoPoint bottomRight = getTileCoordinate(tiles.bottom, tiles.right, zoom, false);
        return findKeyForRequest(topLeft, bottomRight, zoom);
    }
    
    
    /**
     * Group together point with same center and calculate the average score point. 
     * 
     * public only for testing purposes
     * 
     * @param sourcePoints source points. At the end, it can contain different values
     * @return
     */
    public QualityDataPoint[] mapReducePoints(QualityDataPoint[] sourcePoints) {
       if (null == sourcePoints) {
           mLogFacility.v(LOG_HASH, "No data points to map/reduce");
           return null;
       }
       
       for (int scrollIndex=0; scrollIndex<sourcePoints.length; scrollIndex++) {
           QualityDataPoint pointAnalyzed = sourcePoints[scrollIndex];
           if (null == pointAnalyzed) continue;
           //if the average is calculated value by value, int approximation creates too loss
           double sumScore = pointAnalyzed.getScore();
           double sumWeight = pointAnalyzed.getWeight();
           int foundPoints = 1;
           
           for (int checkIndex=scrollIndex+1; checkIndex<sourcePoints.length; checkIndex++) {
               QualityDataPoint pointToMerge = sourcePoints[checkIndex];
               if (null == pointToMerge) continue;
               if (pointAnalyzed.getLatitude() == pointToMerge.getLatitude()
                       && pointAnalyzed.getLongitude() == pointToMerge.getLongitude()
                       && pointAnalyzed.getRadius() == pointToMerge.getRadius()) {
                   sumScore += pointToMerge.getScore() * pointToMerge.getWeight();
                   foundPoints++;
                   sumWeight += pointToMerge.getWeight();
                   sourcePoints[checkIndex] = null;
               }
           }
           
           //calculates new average score if needed
           if (foundPoints > 1) {
               //pointAnalyzed.setScore((int) (sumScore / foundPoints));
               pointAnalyzed.setScore((int) (sumScore / sumWeight));
               mLogFacility.v(LOG_HASH,
                       "New score for point "
                       + pointAnalyzed.getLatitude() + ", " + pointAnalyzed.getLongitude()
                       + ": " + pointAnalyzed.getScore()
                       + " - map/reduce of " + foundPoints + " points"
                       + ", with a total sum of " + sumScore
                       + " and a total weight of " + sumWeight);
           }
       }
       
       //creates new array with compressed values
       int totalNewPoint = 0;
       for (int i=0; i<sourcePoints.length; i++) {
           if (null != sourcePoints[i]) {
               totalNewPoint++;
           }
       }
       
       QualityDataPoint[] finalPoints = new QualityDataPoint[totalNewPoint];
       int finalPointsIndex = 0;
       for (int i=0; i<sourcePoints.length; i++) {
           if (null != sourcePoints[i]) {
               finalPoints[finalPointsIndex] = sourcePoints[i];
               finalPointsIndex++;
           }
       }
       
       return finalPoints; 
    }
    
    
    // ----------------------------------------- Private Methods
    
    private GeoPoint getTileCoordinate(int tileXIndex, int tileYIndex, int zoom, boolean topMargin) {
        int totalLatitude = MAX_MAP_LATITUDE - MIN_MAP_LATITUDE;
        int totalLongitude = MAX_MAP_LONGITUDE - MIN_MAP_LONGITUDE;
        
        int tileLatitude = totalLatitude / zoom;
        int tileLongitude = totalLongitude / zoom;

        GeoPoint point;
        if (topMargin) {
            point = new GeoPoint(tileLatitude * tileXIndex, tileLongitude * tileYIndex);
        } else {
            point = new GeoPoint(tileLatitude * (tileXIndex + 1), tileLongitude * (tileYIndex + 1));
        }

        return point;
    }
    
    /**
     * Calculates approximate distance from two points considering earth radius
     * 
     * How to calculate the right distance between two coordinate points:
     *  http://www.gavaghan.org/blog/free-source-code/geodesy-library-vincentys-formula-java/
     *  http://stackoverflow.com/a/8468979/584134 (usage)
     *  
     * How to calculate a slightly correct distance between two coordinates points:
     *  http://stackoverflow.com/a/837957/584134
     * 
     * @param lat1 upper left latitude, in microdegrees
     * @param lng1 upper left longitude, in microdegrees
     * @param lat2 lower right latitude, in microdesgrees
     * @param lng2 lower right longitude, in microdegrees
     * 
     * @return distance from the points, in meters
     */
    private float distFrom(int lat1, int lng1, int lat2, int lng2) {
        double earthRadius = 3958.75; //in miles
        
        //convert from microdegrees (degrees * 1E6) to coordinates
        float floatLat1 = lat1 / 1000000;
        float floatLng1 = lng1 / 1000000;
        float floatLat2 = lat2 / 1000000;
        float floatLng2 = lng2 / 1000000;
        
        double dLat = Math.toRadians(floatLat2 - floatLat1);
        double dLng = Math.toRadians(floatLng2 - floatLng1);
        double a = Math.sin(dLat/2) * Math.sin(dLat/2) +
                   Math.cos(Math.toRadians(floatLat1)) * Math.cos(Math.toRadians(floatLat2)) *
                   Math.sin(dLng/2) * Math.sin(dLng/2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
        double dist = earthRadius * c;

        int meterConversion = 1609;

        return (float) dist * meterConversion;
    } 
    
    private String findKeyForRequest(
            GeoPoint topLeft,
            GeoPoint bottomRight,
            int zoom) {
        //compose key
        StringBuilder sb = new StringBuilder();
        sb.append("[")
            .append(topLeft.getLatitudeE6())
            .append(",")
            .append(topLeft.getLongitudeE6())
            .append("][")
            .append(bottomRight.getLatitudeE6())
            .append(",")
            .append(bottomRight.getLongitudeE6())
            .append("][")
            .append(zoom)
            .append("]");
        return sb.toString();
    }
    
    private boolean searchWasPerformedInTheLastPeriod() {
        //TODO implement an hashmap of search keys and relative timestamp
        return false;
    }

    private boolean isSimilarSearchAlreadyInQueue(String jobSearchKey) {
        return mJobExecutor.isDataRetrievalSearchInQueue(jobSearchKey);
    }

    

    // ----------------------------------------- Private Classes
    /** Listener for */
    private JobExecutor.JobExecutorListener mJobExecutorListener = new JobExecutor.JobExecutorListener() {

        public void jobCompleted(RainbowJobResult jobResult) {
            
            if (jobResult instanceof DownloadQualityDataJob.Result) {
                DownloadQualityDataJob.Result dataJob = (DownloadQualityDataJob.Result) jobResult;
                String uniqueJobKey = dataJob.getJobUniqueKey();
                ResultOperation<QualityDataPoint[]> result = dataJob.getResultOperation();
                if (result.hasErrors()) {
                    mLogFacility.i(LOG_HASH, "Cannot get data points, error code "
                            + result.getReturnCode()
                            + ", expection: "
                            + (null != result.getException() ? result.getException().getMessage() : "null"));
                    mListener.dataIsAvailable(uniqueJobKey, null);
                } else {
                    //TODO
                    List<QualityDataPoint> dataPoints = new ArrayList<QualityDataPoint>();
                    
                    //check for a empty result and add it to the queue of
                    // empty result with the given timestamp
                    
                    //check for listener presence
                    if (null == mListener) return;
                    
                    //extract data points
                    QualityDataPoint[] dataPointsArray = dataJob.getResultOperation().getResult();
                    //TODO at the end, remove
                    if (null == dataPointsArray) {
                        mLogFacility.v(LOG_HASH, "Received no new data point");
                        //TODO
                        return;
                    }
                    mLogFacility.v(LOG_HASH, "Received " + dataPointsArray.length + " new data points");
                    
                    dataPointsArray = mapReducePoints(dataPointsArray);
                    
                    for (QualityDataPoint dataPoint : dataPointsArray) {
                        dataPoints.add(dataPoint);
                    }
                    
                    //call listener with new data points
                    mListener.dataIsAvailable(uniqueJobKey, dataPoints);
                }
            }
            
        }
    };


}
